Add Hydra micropayments (gateway↔end user)#451
Add Hydra micropayments (gateway↔end user)#451michalrus wants to merge 31 commits intofeat/hydra-paymentsfrom
Conversation
Deploying blockfrost-platform with
|
| Latest commit: |
42da948
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://12949fa4.blockfrost-platform.pages.dev |
| Branch Preview URL: | https://feat-hydra-payments-2.blockfrost-platform.pages.dev |
daa80bd to
9717d49
Compare
e057bc7 to
9403975
Compare
9393aee to
473bb17
Compare
98adc65 to
e394f56
Compare
366d717 to
f5665c2
Compare
There was a problem hiding this comment.
Pull request overview
Adds an SDK Bridge (local HTTP proxy ↔ Gateway WebSocket) and a new Hydra micropayment channel flow between Gateway and end user, alongside Gateway-side WebSocket support for the bridge and Hydra session management.
Changes:
- Introduces new
crates/sdk_bridgebinary that proxies local HTTP requests to the Gateway over/sdk/wsand runs a localhydra-nodeto pay per request. - Adds a new Gateway
/sdk/wsWebSocket route plushydra_server_bridgeto manage customer Hydra controllers and credit tracking. - Refactors Nix devshell Hydra scripts TX-id env var setup to use a shared internal helper.
Reviewed changes
Copilot reviewed 20 out of 21 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| nix/devshells.nix | Uses internal.hydraScriptsEnvVars to populate Hydra scripts TX-id env vars. |
| crates/sdk_bridge/Cargo.toml | New SDK bridge crate dependencies and lint config. |
| crates/sdk_bridge/src/main.rs | New bridge binary entrypoint wiring config, WS client, and HTTP proxy. |
| crates/sdk_bridge/src/config.rs | CLI args + normalization of Gateway WS URL. |
| crates/sdk_bridge/src/http_proxy.rs | Local Axum HTTP server that converts HTTP↔JSON-over-WS and enforces prepaid credits. |
| crates/sdk_bridge/src/ws_client.rs | WS connection loop to Gateway, request/response correlation, pinging, and hydra KEx/tunnel forwarding. |
| crates/sdk_bridge/src/hydra_client/mod.rs | Bridge-side Hydra controller: ceremony, commit, prepay, microtransactions, credit polling. |
| crates/sdk_bridge/src/hydra_client/verifications.rs | Bridge-side Cardano/Hydra verification helpers and subprocess utilities. |
| crates/sdk_bridge/src/protocol.rs | Shared JSON protocol types for bridge↔gateway messages. |
| crates/sdk_bridge/src/types.rs | Network enum + magic numbers used by hydra/cardano-cli invocations. |
| crates/sdk_bridge/src/find_libexec.rs | Helper to locate hydra-node / cardano-cli binaries. |
| crates/gateway/src/main.rs | Registers /sdk/ws route and initializes hydra_bridge manager. |
| crates/gateway/src/lib.rs | Exposes new hydra_server_bridge and sdk_bridge_ws modules. |
| crates/gateway/src/sdk_bridge_ws.rs | Implements Gateway-side bridge WebSocket server + in-memory HTTP dispatch and hydra credit gating. |
| crates/gateway/src/hydra_server_bridge/mod.rs | Gateway-side Hydra manager/controller for bridge customers, incl. KEx and credit monitoring/fanout. |
| crates/gateway/src/hydra_server_bridge/verifications.rs | Gateway-side Cardano/Hydra helpers used by bridge customer controllers. |
| crates/gateway/src/config.rs | Adds [hydra_bridge] configuration section to gateway config model. |
| crates/gateway/config/development.toml | Adds sample [hydra_bridge] config alongside existing [hydra_platform]. |
| crates/gateway/Cargo.toml | Adds tower dependency for ServiceExt::oneshot usage. |
| Cargo.toml | Adds crates/sdk_bridge to workspace members. |
| Cargo.lock | Locks new crate and dependency additions (notably tower). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let config_dir = dirs::config_dir() | ||
| .expect("Could not determine config directory") | ||
| .join("blockfrost-sdk-bridge") | ||
| .join("hydra") | ||
| .join(config.network.as_str()) | ||
| .join("_default"); |
| if (200..400).contains(&response.code) { | ||
| state.bridge.hydra().account_one_request().await; | ||
| } |
| JsonResponse { | ||
| id: request_id_, | ||
| code: code.into(), | ||
| header: vec![], | ||
| body_base64: err, |
| fn error_response(request_id: RequestId, code: u16, msg: String) -> JsonResponse { | ||
| JsonResponse { | ||
| id: request_id, | ||
| code, | ||
| header: vec![], | ||
| body_base64: msg, | ||
| } |
| while let Some(Ok(msg)) = sock_rx.next().await { | ||
| match msg { | ||
| Message::Text(text) => { | ||
| match serde_json::from_str::<BridgeMessage>(&text) { | ||
| Ok(msg) => { | ||
| if event_tx | ||
| .send(BridgeEvent::NewBridgeMessage(msg)) | ||
| .await | ||
| .is_err() | ||
| { | ||
| break; | ||
| } | ||
| }, | ||
| Err(err) => warn!( | ||
| "sdk-bridge-ws: received unparsable text message: {:?}: {:?}", | ||
| text, err, | ||
| ), | ||
| }; | ||
| }, | ||
| Message::Binary(bin) => { | ||
| warn!( | ||
| "sdk-bridge-ws: received unexpected binary message: {:?}", | ||
| hex::encode(bin), | ||
| ); | ||
| }, | ||
| Message::Close(frame) => { | ||
| warn!( | ||
| "sdk-bridge-ws: bridge disconnected (CloseFrame: {:?})", | ||
| frame, | ||
| ); | ||
| let _ignored_failure: Result<_, _> = event_tx | ||
| .send(BridgeEvent::Finish("bridge disconnected".to_string())) | ||
| .await; | ||
| break; | ||
| }, | ||
| Message::Ping(_) | Message::Pong(_) => {}, | ||
| } | ||
| } |
| let minimal_commit: f64 = 1.01 | ||
| * (config.lovelace_per_request | ||
| * config.requests_per_microtransaction | ||
| * config.microtransactions_per_fanout | ||
| + MIN_LOVELACE_PER_TRANSACTION) as f64 | ||
| / 1_000_000.0; | ||
| if config.commit_ada < minimal_commit { | ||
| Err(anyhow!( | ||
| "hydras-manager: Please make sure that configured commit_ada ≥ lovelace_per_request * requests_per_microtransaction * microtransactions_per_fanout + {}.", | ||
| MIN_LOVELACE_PER_TRANSACTION as f64 / 1_000_000.0 | ||
| ))? | ||
| } | ||
|
|
||
| let microtransaction_lovelace: u64 = | ||
| config.lovelace_per_request * config.requests_per_microtransaction; | ||
| if microtransaction_lovelace < MIN_LOVELACE_PER_TRANSACTION { |
| // Schedule the first `PingTick` immediately, otherwise we won’t start | ||
| // checking for ping timeout: |
| BridgeEvent::NewRequest(req) => { | ||
| let request_id = req.request.id.clone(); | ||
| inflight | ||
| .lock() | ||
| .await | ||
| .insert(request_id.clone(), req.respond_to); | ||
| if let Err(err) = | ||
| send_json_msg(&socket_tx, &BridgeMessage::Request(req.request)).await | ||
| { | ||
| loop_error = Err(err); | ||
| break 'event_loop; | ||
| } |
| // This is the most important one for relocatable directories (that keep the initial | ||
| // structure) on Windows, Linux, macOS: | ||
| let current_exe_dir: Option<PathBuf> = | ||
| std::fs::canonicalize(env::current_exe().map_err(|e| e.to_string())?) | ||
| .map_err(|e| e.to_string())? | ||
| .parent() | ||
| .map(|a| a.to_path_buf().join(exe_name)); | ||
|
|
||
| // Similar, but accounts for the `nix-bundle-exe` structure on Linux: | ||
| let current_package_dir: Option<PathBuf> = current_exe_dir | ||
| .clone() | ||
| .and_then(|a| a.parent().map(PathBuf::from)) | ||
| .and_then(|a| a.parent().map(PathBuf::from)); | ||
|
|
||
| let cargo_target_dir: Option<PathBuf> = env::var("CARGO_MANIFEST_DIR") | ||
| .ok() | ||
| .map(|root| PathBuf::from(root).join("target/testgen-hs/extracted/testgen-hs")); | ||
|
|
||
| let docker_path: Option<PathBuf> = Some(PathBuf::from(format!("/app/{exe_name}"))); |
| fn error_response(request_id: RequestId, code: StatusCode, why: String) -> JsonResponse { | ||
| JsonResponse { | ||
| id: request_id, | ||
| code: code.as_u16(), | ||
| header: vec![], | ||
| body_base64: why, | ||
| } |
ginnun
left a comment
There was a problem hiding this comment.
General nitpicking:
Significant code duplication between gateway and bridge — verifications.rs, find_libexec.rs, KeyExchangeRequest/KeyExchangeResponse, and JsonRequest/JsonResponse protocol types are all duplicated nearly verbatim. Any drift would cause silent wire-format incompatibilities. Consider DRY if preferred by you and possible.
Personally 3 or more repetitions is trigger for DRY.
| WaitForIdleAfterClose, | ||
| } | ||
|
|
||
| fn mk_config_dir(network: &Network, customer_machine_id: &str) -> Result<PathBuf> { |
There was a problem hiding this comment.
Hmmm, is this dangerous? What happens if my payload is:
HydraKExRequest: {
machine_id: "../../../../tmp/pwned-by-path-traversal",
platform_cardano_vkey: {},
platform_hydra_vkey: {},
accepted_platform_h2h_port: null,
},
If a valid scenario, we should validate machine_id to contain only hex chars.
|
|
||
| let sdk_state = sdk_bridge_ws::SdkBridgeState::new(base_router.clone(), hydras_bridge_manager); | ||
|
|
||
| let app = base_router |
There was a problem hiding this comment.
The /sdk/ws WebSocket endpoint has no authentication — any client can connect and start consuming Hydra resources (key exchange, hydra-node spawning).
Do sdk/ws need auth? Is authentication planned for a follow-up, or intentionally deferred?
| Pong(u64), | ||
| } | ||
|
|
||
| async fn run_ws_loop( |
There was a problem hiding this comment.
If the WebSocket connection fails or drops, run_ws_loop exits permanently with no reconnection logic. The bridge's HTTP proxy stays alive but returns 503 for every subsequent request — a dead endpoint with no recovery path.
For inflight requests, the cleanup at the end of run_ws_loop correctly sends 503 via error_response. But no new connections are attempted.
| already_exists, | ||
| &state.hydras, | ||
| &req.accepted_platform_h2h_port, | ||
| initial_hydra_kex.take(), |
There was a problem hiding this comment.
Nitpicking:
.take() is evaluated eagerly before the match arms, so the KEx state would be consumed even if an error arm were reached. In the current code this is harmless. The error arms can't fire when initial_hydra_kex is Some — but moving .take() inside the arm that binds initial_kex would be more robust against future refactors.
… drop the `cardano-cli` requirement
Resolves #278
Context
Follow-up to: Add Hydra micropayments (gateway↔platform) #425 – please review that one first!
Here's the 2nd video to aid in review: 2026-02-20-blockfrost-hydra-demo-part-2.mp4